Fix a leak in CurrentValueRelay.#137
Conversation
f3e96b1 to
248f258
Compare
| func forceFinish() { | ||
| self.sink?.shouldForwardCompletion = true | ||
| self.sink?.receive(completion: .finished) | ||
| self.sink = nil | ||
| } |
There was a problem hiding this comment.
Since now cancel and forceFinish are the same you can simplify and maybe just keep the cancel method.
There was a problem hiding this comment.
I think the intent of the method is clearer by leaving it in the forceFinish method. It is called by cancel method of CurrentValueRelay.Subscription and deinit method of CurrentValueRelay.
The method isn't cancelling the subscription, it is finishing the subscription. It just happens to be called from cancel, because of how a CurrentValueRelay works.
|
Very nice catch, thanks! |
Codecov Report
@@ Coverage Diff @@
## main #137 +/- ##
==========================================
- Coverage 95.53% 95.49% -0.04%
==========================================
Files 68 70 +2
Lines 3833 4111 +278
==========================================
+ Hits 3662 3926 +264
- Misses 171 185 +14
Continue to review full report at Codecov.
|
- Test subscription release with different initialization orders - Test withLatestFrom operator scenarios - Mirrors test coverage from CurrentValueRelay (PR CombineCommunity#137) - Verifies fix for issue CombineCommunity#167
Issue 1 (commit)
Scenario 1
Depending on the order in which a CurrentValueRelay and its subscription are deallocated, the relay can leak its stored object.
This does not leak when the containing object is deallocated.
This leaks when the containing object is deallocated.
Other conditions can lead to the leak, but this is the easiest one to reproduce and test. Here's a project which produces the issue.
When I first found this issue, I thought it was a fluke, but the order in which objects are deallocated is deterministic but not guaranteed in Swift. Here's a swift issue which documents the behavior.
Here's what happens to cause the leak.
Original Declaration:
When the containing object is deallocated,
cancellablesis deallocated first. This causescancelto be called on the subscription to the relay. CodeThen the relay is deallocated. Code
This causes forceFinish to be called for the subscription, but
sinkis nil at this point, so nothing happens. CodeAt this point, the relay is deallocated, but the object it stored gets leaked.
This PR fixes the issue by calling
forceFinishin thecancelmethod of the subscription. That forces the sink to clean up its memory.The order of declarations determines how the stream terminates. The subscription will receive a finished event if the relay is deallocated first. If cancellable is deallocated first, the subscription will be canceled and will never receive a finished event.
This is the correct behavior. In the documentation for AnyCancellable, it says:
Once a stream has been canceled, it won't accept any new events. So canceling the stream causes the finished event to be ignored.
Scenario 2 (Project has been updated to show this scenario)
If a
withLatestFromis added to the relay, two objects will be leaked ifcancellablescomes before the declaration of the initial relay.This order of declarations causes a leak when the containing object is deallocated.
The flow is the same as Scenario 1, except setting the sink to nil causes the cancel method in
withLatestFromto be skipped. CodeSince the method is skipped, the value objects for relay
andwithLatestFromRelay` leak.Issue 2 (commit)
Fixing issue 1 led to a crash in the second scenario mentioned above. The original leak and crash caused by the fix were caught for the 'withLatestFrom' operator, but both could happen for any publisher that uses 'Sink.'
Now
cancel()is called in thewithLatestFromoperator which causessink?.cancelUpstream()to be called. In this case, upstream isrelayand has already been canceled. CodeThe
sink?.cancelUpstreamcall thecancelUpstreammethod inSink. CodeThat leads to the
killmethod inDemandBuffer.CodeThis method causes cancel to be called on
relay,which was canceled to start this sequence of calls. Calling cancel twice on aSubscriptioncauses a crash. The Apple Docs states,You can only cancel aSubscriptiononce.